Android UI · Declarative Toolkit · Deep Dive

Jetpack
Compose

Android's declarative UI framework — a complete rethink of how UI is built on Android. No more XML inflation, no more View hierarchies, no more findViewById. Understand the runtime, the composition model, and why this paradigm shift matters at the system level.

@Composable State → UI Recomposition Slot API remember {} LaunchedEffect Modifier Chain LayoutNode
ProfileCard.kt
@Composable
fun ProfileCard(
  user: User,
  onFollow: () -> Unit
) {
  var following by remember {
    mutableStateOf(false)
  }
 
  Card(modifier = Modifier
    .fillMaxWidth()
    .padding(16.dp)) {
    Text(text = user.name)
    Button(onClick = {
      following = !following
      onFollow()
    }) {
      Text(if (following) "Following"
          else "Follow")
    }
  }
}
01 — The Problem Space

Why Compose?
What Was Wrong with XML?

Android's original View system was built in 2008, when smartphones had 192MB RAM and single-core CPUs. It was designed around XML layout files inflated into View trees, imperative mutation via setText() and setVisibility(), and a single-threaded main loop that both measured/draws and handles events.

This model accumulated 15+ years of technical debt: double-taxation of layout passes, view state duplication (the View holds state the developer also tracks), no type safety in view binding, and cascading invalidate() chains that redrew far more than necessary. As apps grew more dynamic, developers wrote increasingly fragile synchronization code between data and UI.

01
📜

Imperative → Declarative

XML+View says how to build UI step by step. Compose says what the UI should look like for a given state — the runtime figures out how to get there.

02
🔄

No View Recycling Bugs

RecyclerView required careful onBindViewHolder resets. In Compose, the composition function is the binding — state is always consistent with what's rendered.

03
🧩

Composability Over Inheritance

The View hierarchy was a deep inheritance tree (View → TextView → EditText → …). Compose uses function composition — combine small functions, not extend big classes.

04
🎯

Type-Safe Everything

No more R.id.button string references and runtime ClassCastExceptions. Parameters are typed Kotlin values. The compiler catches mismatches before the app runs.

⚡ The Core Insight Behind Compose

React, Flutter, SwiftUI, and Compose all share the same insight: UI is a function of state. Given the same state, the same UI is always produced. The framework's job is to efficiently reconcile the current UI tree with the new one produced by calling your functions — not to let you mutate UI imperatively in ways that can get out of sync with your data model.

02 — The Composition Model

What @Composable
Actually Means

The @Composable annotation is more than a marker — it changes the calling convention of the function at the compiler level. The Compose compiler plugin transforms annotated functions to accept an invisible Composer parameter and a bitmask of changed flags. This is what enables the runtime to track what was called, in what order, and skip re-executing unchanged parts.

A @Composable function is not a constructor. It doesn't return a View. Instead, calling it emits nodes into a slot table — a positional, gap-buffered data structure maintained by the Compose runtime in memory. Each call site is identified by its position in the source code (its "key"), not by an ID you assign.

🔬 The Slot Table

The Compose runtime maintains a slot table (a flat array indexed by "group keys" — integer hashes of call site positions). State values, remembered values, and child groups are stored here. On recomposition, the runtime walks this table and compares old vs new slots — only emitting changes to the actual UI nodes when values differ. This is Compose's equivalent of React's virtual DOM diffing.

Kotlin + Compose Compiler Transform (conceptual)
// What you write:
@Composable
fun Greeting(name: String) {
    Text("Hello, $name")
}

// What the compiler generates (simplified):
fun Greeting(
    name: String,
    composer: Composer,   // invisible param added
    changed: Int           // bitmask: which params changed
) {
    // Start a "group" — identifies this call site
    composer.startRestartGroup(key)

    val dirty = changed or /* track if name changed */
    if (dirty != 0) {
        // Only re-execute if something changed
        Text("Hello, $name", composer, ...)
    }

    composer.endRestartGroup()
        ?.updateScope { c, _ -> Greeting(name, c, 1) }
}

// Key rules for composable functions:
// 1. Must be called from another @Composable (or setContent)
// 2. Can NOT be called from regular functions
// 3. Must NOT have side effects — use Effect APIs instead
// 4. Can be called in any order, multiple times (idempotent)
03 — Recomposition

Smart Re-execution:
Only What Changed

Recomposition is Compose's mechanism for updating the UI when state changes. It's not like React's full component tree re-render — it's surgical, positional, and skippable at the function level.

State change in CounterState.count → triggers recomposition:

App()
Screen()
Counter()
state changed here
Header() ✓ skipped
params are @Stable, values unchanged
Footer() ✓ skipped
no state reads inside
CounterText() ↻ recomposed
reads count directly, param changed
UserAvatar() ∅ skipped
@Composable with same stable params

Stability & Skippability

A composable is skippable if all its parameters are stable. Stable types are those the compiler can prove won't change without notifying Compose: primitives, Strings, @Stable-annotated classes, and @Immutable data classes.

Unstable types — like plain List<T>, arbitrary classes with var properties, or interfaces — force the composable to always recompose even if the values didn't change. This is one of the most common performance pitfalls in Compose.

Kotlin — Stable vs Unstable
// ✗ UNSTABLE — List is a plain interface, not @Stable
@Composable
fun ItemList(items: List<Item>) { ... }
// Compose can't skip this — always recomposes

// ✓ STABLE — ImmutableList from kotlinx.collections.immutable
@Composable
fun ItemList(items: ImmutableList<Item>) { ... }

// ✓ Or annotate your wrapper class
@Immutable
data class ItemsState(val items: List<Item>)

// ✓ Or use @Stable for classes that notify on change
@Stable
class MyViewModel : ViewModel() { ... }

Key & Positional Memoization

Compose identifies composable call sites by their position in the source code. When you call the same composable in a loop, each iteration gets a different key derived from its index. If items are reordered, Compose can get confused — use key(id) { ... } to provide a stable semantic key.

Kotlin — key() for list stability
// ✗ Without key: reorder = all items recompose
LazyColumn {
    items(users) { user ->
        UserRow(user)  // keyed by position — fragile
    }
}

// ✓ With key: reorder = only moved items recompose
LazyColumn {
    items(users, key = { it.id }) { user ->
        UserRow(user)  // keyed by stable ID
    }
}

// ✓ Manual key for conditional composables
key(selectedTab) {
    TabContent(selectedTab)  // discards state on tab change
}
04 — State Management

State in Compose:
The Full Picture

State in Compose is explicit and observable. When a State<T> object's value changes, every composable that read it during the last composition is scheduled for recomposition. This read-tracking is automatic — the Compose runtime instruments every property read on State objects.

1

mutableStateOf() — the atom

Creates a MutableState<T> backed by a snapshot-aware state object. Any composable reading .value subscribes to changes. Write to .value from any thread — the Compose runtime schedules recomposition on the main thread.

2

remember {} — survives recomposition

Wraps a calculation whose result is stored in the slot table. The lambda runs once; subsequent recompositions return the stored value. remember(key) { ... } invalidates and re-runs when the key changes.

3

rememberSaveable {} — survives process death

Like remember but also serializes to the SavedStateHandle (same mechanism as onSaveInstanceState). Works with any Parcelable, Serializable, or types with a custom Saver.

4

State hoisting — single source of truth

Move state up to the lowest common ancestor that needs it. Make composables stateless by taking state + lambda parameters. This enables testability, reusability, and preview support.

5

ViewModel + StateFlow — screen-level state

For state that survives rotation or navigation, use ViewModel with StateFlow. Collect in composition via collectAsStateWithLifecycle() — automatically pauses collection when the lifecycle is stopped.

Kotlin — State patterns
// Local ephemeral state
@Composable
fun ExpandableCard() {
    var expanded by remember { mutableStateOf(false) }

    Card(onClick = { expanded = !expanded }) {
        AnimatedVisibility(visible = expanded) {
            FullContent()
        }
    }
}

// State hoisting — stateless composable
@Composable
fun ExpandableCard(
    expanded: Boolean,        // state in
    onToggle: () -> Unit   // event out
) { ... }

// ViewModel screen state
@Composable
fun HomeScreen(viewModel: HomeViewModel = viewModel()) {
    val uiState by viewModel.uiState
        .collectAsStateWithLifecycle()

    when (val s = uiState) {
        is Loading  -> LoadingSpinner()
        is Success  -> ContentScreen(s.data)
        is Error    -> ErrorScreen(s.message)
    }
}

// rememberSaveable with custom Saver
val color by rememberSaveable(
    stateSaver = ColorSaver
) { mutableStateOf(Color.Red) }
Kotlin — Snapshot system
// Compose's snapshot system enables safe multi-threaded reads
// State reads are tracked per-snapshot (like database MVCC)

// Write from background thread safely:
withContext(Dispatchers.Default) {
    // Snapshot.withMutableSnapshot groups writes atomically
    Snapshot.withMutableSnapshot {
        state1.value = newVal1
        state2.value = newVal2
        // observers notified once, not twice
    }
}

// derivedStateOf — computed state, only updates when result changes
val filteredList by remember {
    derivedStateOf { items.filter { it.isActive } }
}
// Recomposes only when the filtered result changes,
// NOT every time items list changes (may be same result)
05 — Side Effects & Effect Handlers

Escaping Composition:
The Effect APIs

Composable functions must be pure — no side effects in the function body. But real apps need to: launch coroutines, subscribe to flows, register callbacks, and clean up resources. The Effect APIs provide controlled escape hatches.

LaunchedEffect(key)

Coroutine tied to composition

Launches a coroutine in composition scope. Cancelled and re-launched when key changes. Cancelled when the composable leaves composition. Use for: loading data on entry, animations, one-time events from state changes.

rememberCoroutineScope()

Scope for event handlers

Returns a CoroutineScope bound to the composable's lifetime. Use inside click handlers and callbacks — not for effects that should run on composition. Scope is cancelled when composable leaves.

SideEffect { }

Sync with non-Compose code

Runs after every successful recomposition. Use to push Compose state into non-Compose systems (analytics, legacy View references, Firebase). Not cancellable — guaranteed to run.

DisposableEffect(key)

Register + cleanup callbacks

Like LaunchedEffect but synchronous. Has an onDispose { } block that runs when the key changes or composable leaves. Perfect for: LifecycleObserver, sensors, BroadcastReceiver, view event listeners.

produceState(initial)

Non-Compose → State bridge

Launches a coroutine that can write to a State value. Use to convert callbacks, Flows, or suspend functions into Compose state. The state's lifetime matches the composable.

snapshotFlow { }

State → Flow bridge

Converts Compose state reads into a Flow. Emits a new value whenever the state read inside the block changes. Use to debounce state, combine with other flows, or observe from outside composition.

Kotlin — Effect API patterns
@Composable
fun SearchScreen(query: String) {
    val results = remember { mutableStateOf<List<Item>>(emptyList()) }

    // Re-launches when query changes, cancels previous search
    LaunchedEffect(query) {
        if (query.isEmpty()) { results.value = emptyList(); return@LaunchedEffect }
        delay(300)  // debounce — cancelled if query changes during delay
        results.value = repository.search(query)
    }

    // Scope for user-triggered actions
    val scope = rememberCoroutineScope()

    Button(onClick = {
        scope.launch { repository.saveSearch(query) }  // event handler
    }) { Text("Save") }
}

@Composable
fun LifecycleAwareScreen(onBackground: () -> Unit) {
    val lifecycle = LocalLifecycleOwner.current.lifecycle

    // Register/unregister observer safely
    DisposableEffect(lifecycle) {
        val observer = LifecycleEventObserver { _, event ->
            if (event == Lifecycle.Event.ON_STOP) onBackground()
        }
        lifecycle.addObserver(observer)
        onDispose { lifecycle.removeObserver(observer) } // always cleaned up
    }
}
06 — Runtime Phases

Three Phases Per Frame:
Composition → Layout → Draw

Every frame that Compose renders goes through three distinct phases. Understanding them is critical for performance — reading state in the wrong phase causes unnecessary work in other phases.

PHASE 01
Composition
Composable functions execute. State reads are tracked. The slot table is updated. Emits LayoutNode tree describing what should exist.
PHASE 02
Layout
Each LayoutNode measures its children and places them. Constraints flow down (parent tells child max size), sizes flow up (child reports its size). Single-pass by design.
PHASE 03
Drawing
Each node draws itself into a Canvas. Compose generates a RenderNode display list per composable — hardware-accelerated via the GPU just like Views.
OPTIMIZE
Phase Skipping
If only drawing properties change (color, alpha, translation via graphicsLayer), Composition and Layout are skipped entirely — only the Draw phase runs. Critical for animations.
PERF TIP
Defer Reads
Read animated values inside DrawScope or Modifier.drawWithContent lambdas — not at composition time. This keeps animation entirely in the Draw phase, avoiding 60fps recompositions.
Kotlin — Defer state reads to the right phase
// ✗ BAD: reads animatable at composition time → recomposition every frame
@Composable
fun BadAnimation() {
    val offsetX by animateFloatAsState(100f)
    Box(Modifier.offset(x = offsetX.dp))  // ← read at composition
}

// ✓ GOOD: defer read to Layout phase via lambda offset
@Composable
fun GoodAnimation() {
    val offsetX by animateFloatAsState(100f)
    Box(Modifier.offset { IntOffset(offsetX.roundToInt(), 0) })
    // Lambda is invoked at Layout phase — no recomposition!
}

// ✓ BEST for pure visual: defer to Draw phase via graphicsLayer
@Composable
fun BestAnimation() {
    val alpha by animateFloatAsState(1f)
    Box(Modifier.graphicsLayer { this.alpha = alpha })
    // graphicsLayer lambda deferred to Draw — skips Composition + Layout
}
07 — Compose vs XML Views

The Mental Model
Migration Map

Moving from XML + View to Compose isn't just a syntax change — it's a paradigm shift. Every imperative View concept has a declarative Compose equivalent, but the mental model is fundamentally different.

TextView + setText()
Text(text = value)
RecyclerView + Adapter
LazyColumn / LazyRow
ConstraintLayout XML
ConstraintLayout (Compose)
Fragment + BackStack
NavHost + composable()
onSaveInstanceState
rememberSaveable { }
View.OnClickListener
onClick = { } lambda
View.setVisibility(GONE)
if (visible) { Component() }
Custom View (onDraw)
Canvas { drawRect(...) }
Animator / ValueAnimator
animate*AsState / Animatable
LayoutInflater
setContent { } / ComposeView

Interoperability

Compose and Views can coexist in the same app — you don't have to migrate everything at once.

Kotlin — Interop patterns
// Compose INSIDE a View (Fragment/Activity)
val composeView = ComposeView(context).apply {
    setContent {
        MyComposable()  // compose tree starts here
    }
}

// A View INSIDE Compose (for legacy widgets)
@Composable
fun LegacyMapView() {
    AndroidView(
        factory = { ctx -> MapView(ctx).apply { ... } },
        update  = { mapView -> mapView.moveCamera(...) }
    )
}

// Theme bridging — Material3 Compose ↔ AppCompat theme
@Composable
fun App() {
    MdcTheme {          // MaterialThemeAdapter from accompanist
        MyScreens()    // colors/typography from XML theme
    }
}
DimensionXML ViewsCompose
UI definitionXML + KotlinKotlin only
Null safetyRuntime NPE riskCompile-time
PreviewsLayout Editor@Preview (faster)
TestingEspresso (slow)ComposeTestRule
AnimationVerbose APIanimate*AsState
Custom drawingonDraw overrideCanvas DSL
Mature ecosystem15+ yearsCatching up
Binary sizeSmaller+~1.5MB runtime
08 — Design Philosophy

Why Google Rebuilt
Everything from Scratch

The Unidirectional Data Flow Bet

Compose is built entirely around unidirectional data flow: state flows down through composable parameters, events flow up through lambda callbacks. This wasn't an accident — it's the lesson learned from years of watching developers fight two-way data binding bugs, Adapter notifyDataSetChanged() mysteries, and Fragment state inconsistencies.

Making data flow in one direction means there's always a single source of truth, always a clear path from state change to screen update, and always a clear boundary between "what the UI shows" and "what the user did." This is the same bet that React, Flutter, and SwiftUI all made — and it's now the dominant paradigm for production UI across every platform.

Functions Over Classes

The decision to make composables functions rather than classes was radical. It means: no constructor, no fields, no inheritance. Every piece of UI is a function that takes data in and emits nodes out. This enforces composition-over-inheritance at the language level — you build complex UI by calling functions, not by subclassing ConstraintLayout.

The Compiler as UI Framework

One of Compose's most innovative decisions was to put intelligence in the compiler rather than the runtime. The Compose compiler plugin analyzes your code at build time, inserts stability inference, tracks parameter change bitmasks, and generates restart lambdas. This shifts work that would normally be runtime overhead (like React's reconciler diffing) to build time — resulting in a runtime that's faster and more predictable.

This also means Compose's performance characteristics are visible at the code level: you can see from the types whether a composable will be skippable, whether a lambda will be stable, and whether a state read will cause layout or just drawing — without running the app.

The Trade-offs Android Made

Compose's binary size overhead (~1.5MB), its higher initial compile times, and its learning curve are real costs. The runtime's slot table and snapshot system add memory overhead that a hand-crafted View implementation doesn't have. For very simple screens, raw XML is still faster to write and lighter to ship.

But the trade-off is justified: as UI complexity grows, the imperative View model's costs (bugs, synchronization code, state duplication) grow non-linearly. Compose's costs are roughly fixed — the framework handles the complexity, and your code stays proportional to what the UI actually does.